<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>实时流式 ASR 演示客户端</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <!-- ✨ 使用 Tailwind CDN 快速获得现代化外观，无需本地构建 -->
  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 text-gray-800">
  <!--
    █████████████████████████████████████████████████████████████████████████
    ☆ 本文件示例了如何从浏览器采集麦克风音频 → 通过 WebSocket 推送至后端 →
      实时接收 JSON 事件并在页面上渲染。
    ☆ 代码尽量保持原生，无依赖构建工具；Tailwind 仅做样式。
    ☆ 所有注释均为中文，方便理解；如需英文可自行翻译。
    █████████████████████████████████████████████████████████████████████████
  -->

  <div class="max-w-3xl mx-auto p-4 space-y-6">
    <!-- 标题 -->
    <h1 class="text-3xl font-bold text-center">实时流式 ASR WebSocket 客户端</h1>

    <!-- 连接设置面板 -->
    <section class="bg-white shadow rounded-lg p-6 space-y-4">
      <h2 class="text-xl font-semibold">连接设置</h2>

      <!-- 服务器地址（含端点）输入 -->
      <div>
        <label for="wsUrl" class="block mb-1 font-medium">服务器 WebSocket 地址</label>
        <input id="wsUrl" type="text" class="w-full border rounded px-3 py-2"
               value="ws://localhost:17556/ws/transcribe" />
      </div>

      <!-- 三个查询参数：sv / lang / wake -->
      <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
        <div>
          <label class="block mb-1 font-medium">启用说话人验证</label>
          <select id="svSelect" class="w-full border rounded px-2 py-1">
            <option value="false">false (禁用)</option>
            <option value="true">true (启用)</option>
          </select>
        </div>
        <div>
          <label class="block mb-1 font-medium">选择语言</label>
          <select id="langSelect" class="w-full border rounded px-2 py-1">
            <!-- 依据 FunASR 可选语言，自行调整 -->
            <option value="auto">auto (自动侦测)</option>
            <option value="zh">zh (中文)</option>
            <option value="en">en (英文)</option>
          </select>
        </div>
        <div>
          <label class="block mb-1 font-medium">启用语音唤醒</label>
          <select id="wakeSelect" class="w-full border rounded px-2 py-1">
            <option value="true">true (启用)</option>
            <option value="false">false (禁用)</option>
          </select>
        </div>
      </div>

      <!-- 连接 / 断开按钮 -->
      <div class="flex gap-4 pt-2">
        <button id="connectBtn" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">连接</button>
        <button id="disconnectBtn" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded" disabled>断开</button>
        <span id="status" class="self-center text-sm text-gray-600">尚未连接</span>
      </div>
    </section>

    <!-- 实时转写 / 事件日志 -->
    <section class="bg-white shadow rounded-lg p-6">
      <h2 class="text-xl font-semibold mb-2">实时转写 / 事件日志</h2>
      <!-- 使用 pre 便于保留换行；高度可滚动 -->
      <pre id="log" class="h-96 overflow-y-auto bg-gray-100 p-3 rounded text-sm leading-relaxed"></pre>
    </section>
  </div>

  <!-- ==================== 前端逻辑脚本 ==================== -->
  <script>
    /* ----------------------------------------------------------------------
     *         全局变量 & DOM 元素
     * --------------------------------------------------------------------*/
    let ws = null;                   // WebSocket 实例
    let audioContext = null;         // Web Audio 的 AudioContext
    let processor = null;            // ScriptProcessorNode (采集 PCM)
    let input = null;                // MediaStreamAudioSourceNode (麦克风输入)
    let isRecording = false;         // 录音状态标记
    const SAMPLE_RATE = 16000;       // 服务端要求 16 kHz

    // DOM 元素引用（缓存避免多次查询）
    const logEl = document.getElementById("log");
    const statusEl = document.getElementById("status");
    const connectBtn = document.getElementById("connectBtn");
    const disconnectBtn = document.getElementById("disconnectBtn");

    /* ----------------------------------------------------------------------
     *         工具函数
     * --------------------------------------------------------------------*/
    /**
     * 向日志面板追加一行文本
     * @param {string} msg - 要显示的文本
     * @param {string} color - 字体颜色，默认黑色
     */
    function appendLog(msg, color = "black") {
      const span = document.createElement("span");
      span.style.color = color;
      span.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n`;
      logEl.appendChild(span);
      // 滚动到底部，保证最新内容可见
      logEl.scrollTop = logEl.scrollHeight;
    }

    /**
     * 将 Float32Array (任意采样率) → 下采样到 outputSampleRate → 转 Int16Array
     * 说明：为简单起见，这里用均值法进行降采样；如需更高音质可使用 offlineAudioContext 或 speex resampler。
     */
    function downsampleBuffer(buffer, inputSampleRate, outputSampleRate) {
      if (outputSampleRate === inputSampleRate) {
        // 直接量化为 Int16
        return Int16Array.from(buffer.map(n => Math.max(-1, Math.min(1, n)) * 0x7FFF));
      }
      const sampleRateRatio = inputSampleRate / outputSampleRate;
      const newLength = Math.round(buffer.length / sampleRateRatio);
      const result = new Int16Array(newLength);
      let offsetResult = 0;
      let offsetBuffer = 0;
      while (offsetResult < result.length) {
        const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
        // 均值采样
        let accum = 0, count = 0;
        for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
          accum += buffer[i];
          count++;
        }
        result[offsetResult] = Math.max(-1, accum / count) * 0x7FFF;
        offsetResult++;
        offsetBuffer = nextOffsetBuffer;
      }
      return result;
    }

    /* ----------------------------------------------------------------------
     *         连接 / 断开 WebSocket
     * --------------------------------------------------------------------*/
    connectBtn.onclick = async () => {
      // 已经连上就不重复连接
      if (ws) {
        appendLog("已连接，忽略重复连接请求", "orange");
        return;
      }
      // 构造 ws://host:port/ws/transcribe?sv=…&lang=…&wake=…
      const base = document.getElementById("wsUrl").value.trim();
      const sv = document.getElementById("svSelect").value;
      const lang = document.getElementById("langSelect").value;
      const wake = document.getElementById("wakeSelect").value;
      const url = `${base}?sv=${sv}&lang=${lang}&wake=${wake}`;
      appendLog(`尝试连接 ${url} …`, "gray");

      // 创建 WebSocket 对象（失败会抛异常）
      try {
        ws = new WebSocket(url);
      } catch (err) {
        appendLog(`创建 WebSocket 失败：${err}`, "red");
        return;
      }

      ws.binaryType = "arraybuffer"; // 告诉浏览器我们发送的是原始字节

      // --- 事件回调 ---
      ws.onopen = () => {
        appendLog("✅ WebSocket 已连接", "green");
        statusEl.textContent = "已连接";
        connectBtn.disabled = true;
        disconnectBtn.disabled = false;
        startRecording();   // 连接成功后开始采集麦克风
      };

      ws.onmessage = (event) => {
        // 服务器统一发送 JSON 格式：{code, info, data, event}
        const data = event.data;
        try {
          const obj = JSON.parse(data);
          const evt = obj.event ?? "message";
          const txt = obj.data || obj.info;
          appendLog(`${evt} ➜ ${txt}`, "blue");
        } catch (_) {
          appendLog(`收到非 JSON 数据：${data}`, "purple");
        }
      };

      ws.onerror = (e) => {
        appendLog(`WebSocket 错误：${e.message}`, "red");
      };

      ws.onclose = () => {
        appendLog("❌ WebSocket 已关闭", "red");
        statusEl.textContent = "已断开";
        connectBtn.disabled = false;
        disconnectBtn.disabled = true;
        stopRecording(); // 停止音频流
        ws = null;
      };
    };

    // 断开连接按钮
    disconnectBtn.onclick = () => {
      ws?.close();
    };

    /* ----------------------------------------------------------------------
     *         录音 → PCM 推流
     * --------------------------------------------------------------------*/
    async function startRecording() {
      if (isRecording) return;
      // 1. 浏览器向用户请求麦克风权限
      let stream;
      try {
        stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
      } catch (err) {
        appendLog(`获取麦克风失败：${err}`, "red");
        return;
      }

      // 2. 创建 AudioContext (Chrome 下默认 48 kHz)
      audioContext = new (window.AudioContext || window.webkitAudioContext)();
      const inputSampleRate = audioContext.sampleRate;
      appendLog(`AudioContext sampleRate = ${inputSampleRate}`, "gray");

      // 3. 将麦克风流变为音频节点
      input = audioContext.createMediaStreamSource(stream);

      // 4. 创建 ScriptProcessorNode：4096 帧缓冲，单声道
      //    ScriptProcessor 已弃用，但普遍兼容；生产环境可换 AudioWorklet。
      processor = audioContext.createScriptProcessor(4096, 1, 1);

      // 每次缓冲区 full 回调，提取数据并发送
      processor.onaudioprocess = (e) => {
        const inputData = e.inputBuffer.getChannelData(0);      // Float32[]
        const pcm16 = downsampleBuffer(inputData, inputSampleRate, SAMPLE_RATE);
        // 发送二进制：Int16Array.buffer 即 ArrayBuffer
        if (ws && ws.readyState === WebSocket.OPEN) {
          ws.send(pcm16.buffer);
        }
      };

      // 5. 节点串联：mic → processor → (destination，可忽略)
      input.connect(processor);
      processor.connect(audioContext.destination);

      isRecording = true;
      appendLog("🎙️ 开始录音并推流", "green");
    }

    /** 停止录音并释放资源 */
    function stopRecording() {
      if (!isRecording) return;
      processor.disconnect();
      input.disconnect();
      audioContext.close();
      isRecording = false;
      appendLog("⏹️ 停止录音", "gray");
    }
  </script>
</body>
</html>
